Skip to content

fix(agent): recover tools after stream parse errors#6136

Open
usr-bin-roygbiv wants to merge 1 commit into
can1357:mainfrom
usr-bin-roygbiv:fix/openai-responses-midstream-parse-retry
Open

fix(agent): recover tools after stream parse errors#6136
usr-bin-roygbiv wants to merge 1 commit into
can1357:mainfrom
usr-bin-roygbiv:fix/openai-responses-midstream-parse-retry

Conversation

@usr-bin-roygbiv

Copy link
Copy Markdown

What

  • recover completed registered tool calls after explicit transient stream JSON parse failures
  • drop incomplete calls and keep unknown, refused, sensitive, and non-stream error turns non-executable
  • classify persisted parser diagnostics narrowly without weakening provider-context Error classification

Why

OpenAI Responses can complete a tool call and then emit a malformed non-trailing SSE event. OMP 17.0.5 surfaced JSON Parse error: Unterminated string, but the agent loop only recovered completed calls for stream_read_error, so the completed tool never executed. The same failure shape was observed independently on macOS and Linux sessions.

Testing

  • bun test packages/agent/test/agent-loop.test.ts packages/agent/test/agent.test.ts packages/ai/test/error-aierr.test.ts packages/ai/test/anthropic-retry.test.ts — 125 pass
  • bun check — pass
  • bun run ci:test:smoke — pass
  • deterministic source-CLI smoke in cmux on macOS — completed read, malformed middle SSE JSON frame, recovered tool result, continued to OMP_MIDSTREAM_FINAL_LOCAL_OK
  • deterministic source-CLI smoke in native tmux on Linux — same sequence, continued to OMP_MIDSTREAM_FINAL_REMOTE_OK

  • bun check passes
  • Tested locally
  • CHANGELOG updated (if user-facing)

@github-actions github-actions Bot added the vouched Passed the vouch gate label Jul 20, 2026
@roboomp roboomp added agent Agent runtime planning and orchestration fix provider:openai Provider-specific issues for openai providers LLM provider-specific issues review:p1 triaged labels Jul 21, 2026

@roboomp roboomp left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — mergeable after a touch. Thanks for the tight, root-cause fix.

What works well:

  • recoverTransientErrorToolTurn (agent-loop.ts:1625) now extends recovery to explicit transient stream parse errors while correctly gating out refusal/sensitive turns via both stopDetails.type and .category, and still requires every retained call to be a known available tool. Combined with retainCompletedToolCalls dropping non-toolcall_end calls, only fully-parsed, known tools execute — the incomplete-sibling and mixed-known/unknown tests confirm this.
  • isTransientStreamParseError (flags.ts:513) keeps the existing Error path untouched (verified: retryable.ts, anthropic.ts:2590, openai-responses.ts:182 all pass Error objects), so the new string branch is additive and only reached by the agent-loop's persisted stopDetails.explanation/errorMessage strings. No regression to provider retry classification.
  • Strong branch coverage: parse-recovery, partial-recovery, wrapped-refusal, mixed-known/unknown, content-only, and ordinary-transient cases each assert observable message/tool-execution outcomes, not plumbing. Changelog entry present under [Unreleased]. bun test green locally (agent-loop 65 pass, error-aierr 18 pass).

One open item (non-blocking, inline): the string-vs-Error regex asymmetry in flags.ts is deliberate and test-locked but undocumented — the doc comment reads as if both inputs classify identically. A one-line note would prevent a future "looks like a bug" edit.

No correctness or contract concerns; ranking P1 only for that doc gap. Maintainer call on whether to require the comment before merge.


/** Transient stream corruption where the response was truncated mid-JSON. */
export function isTransientStreamParseError(error: unknown): boolean {
if (typeof error === "string") return STREAM_PARSE_DIAGNOSTIC_PATTERN.test(error);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the string branch (STREAM_PARSE_DIAGNOSTIC_PATTERN, narrow — rejects bare truncated/end of file) and the Error branch (STREAM_PARSE_TRUNCATION_PATTERN, broad — matches them) now diverge deliberately. The test at error-aierr.test.ts:148-149 locks this in, and it's the right call (persisted stopDetails.explanation strings are lower-signal than a live Error from the transport), but the doc comment on line 512 still just says "truncated mid-JSON" and gives no hint that the two input types classify differently. A one-line note on why strings get the stricter pattern would save the next reader from assuming the asymmetry is a bug. Non-blocking.

@roboomp roboomp left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — mergeable after an optional touch. Thanks for the tight, root-cause fix.

  • recoverTransientErrorToolTurn (agent-loop.ts:1625) extends recovery to explicit transient stream parse errors, gated so refusal/sensitive turns (checked on both stopDetails.type and .category) stay non-executable and every retained call must be a known tool. With retainCompletedToolCalls dropping any call lacking a toolcall_end, only fully-parsed known tools run.
  • isTransientStreamParseError (flags.ts:513): the new string branch is additive — the three other callers (retryable.ts:64, anthropic.ts:2590, openai-responses.ts:182) pass Error objects and keep the unchanged broad pattern, so provider retry classification is unaffected.
  • Branch coverage asserts observable outcomes (recovery, partial-recovery, wrapped-refusal, mixed known/unknown, content-only, ordinary-transient); changelog present; local tests green (agent-loop 65 pass, error-aierr 18 pass).

One non-blocking inline nit: the deliberate narrow-string vs broad-Error regex asymmetry in flags.ts is test-locked but undocumented — a one-line comment would prevent a future "looks like a bug" edit. No correctness or contract concerns.

@roboomp roboomp left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — mergeable after an optional touch. Thanks for the tight, root-cause fix.

  • recoverTransientErrorToolTurn (agent-loop.ts:1625) extends recovery to explicit transient stream parse errors, gated so refusal/sensitive turns (checked on both stopDetails.type and .category) stay non-executable and every retained call must be a known tool. With retainCompletedToolCalls dropping any call lacking a toolcall_end, only fully-parsed known tools run.
  • isTransientStreamParseError (flags.ts:513): the new string branch is additive — the three other callers (retryable.ts:64, anthropic.ts:2590, openai-responses.ts:182) pass Error objects and keep the unchanged broad pattern, so provider retry classification is unaffected.
  • Branch coverage asserts observable outcomes; changelog present; local tests green (agent-loop 65 pass, error-aierr 18 pass).

Non-blocking nit: the deliberate narrow-string vs broad-Error regex asymmetry in flags.ts is test-locked but undocumented — a one-line comment would help. No correctness or contract concerns.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent Agent runtime planning and orchestration fix provider:openai Provider-specific issues for openai providers LLM provider-specific issues review:p1 triaged vouched Passed the vouch gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants